/*
 * Copyright (C) 2000 - 2024 Silverpeas
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * As a special exception to the terms and conditions of version 3.0 of
 * the GPL, you may redistribute this Program in connection with Free/Libre
 * Open Source Software ("FLOSS") applications as described in Silverpeas's
 * FLOSS exception.  You should have received a copy of the text describing
 * the FLOSS exception, and it is also available here:
 * "https://www.silverpeas.org/legal/floss_exception.html"
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */
package org.silverpeas.core.webapi.upload;

import org.apache.commons.io.FilenameUtils;
import org.silverpeas.core.admin.component.model.ComponentFileFilterParameter;
import org.silverpeas.core.annotation.WebService;
import org.silverpeas.core.io.upload.UploadSession;
import org.silverpeas.core.io.upload.UploadSessionFile;
import org.silverpeas.core.notification.message.MessageNotifier;
import org.silverpeas.core.security.authorization.ComponentAccessControl;
import org.silverpeas.core.util.JSONCodec.JSONObject;
import org.silverpeas.kernel.bundle.LocalizationBundle;
import org.silverpeas.kernel.bundle.ResourceLocator;
import org.silverpeas.kernel.util.StringUtil;
import org.silverpeas.core.util.UnitUtil;
import org.silverpeas.core.util.file.FileRepositoryManager;
import org.silverpeas.kernel.logging.SilverLogger;
import org.silverpeas.core.web.http.RequestParameterDecoder;
import org.silverpeas.core.web.rs.RESTWebService;
import org.silverpeas.core.web.rs.annotation.Authenticated;

import javax.inject.Inject;
import javax.ws.rs.Consumes;
import javax.ws.rs.DELETE;
import javax.ws.rs.POST;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.text.MessageFormat;
import java.util.Optional;
import java.util.concurrent.Semaphore;
import java.util.function.UnaryOperator;

import static org.silverpeas.core.web.util.IFrameAjaxTransportUtil.*;

/**
 * A REST Web resource that permits to upload files. It has to be used with one of the following
 * plugins:
 * <ul>
 * <li>silverpeas-filUpload.js: useful to handle file upload on resource creation</li>
 * <li>silverpeas-ddUpload.js: useful to handle drag & drop file upload on existing resources</li>
 * </ul>
 * @author Yohann Chastagnier
 */
@WebService
@Path(FileUploadResource.PATH)
@Authenticated
public class FileUploadResource extends RESTWebService {

  private static final Semaphore requestLimit = new Semaphore(50, true);
  static final String PATH = "fileUpload";


  @Inject
  private ComponentAccessControl componentAccessController;

  /**
   * Performs some verifications before starting a file upload.
   * All the verifications are checked again on the effective upload (security).
   * @return the result of the verification: HTTP OK.
   */
  @POST
  @Path("verify")
  @Consumes(MediaType.MULTIPART_FORM_DATA)
  public Response verify() {
    FileUploadVerifyData fileUploadVerifyData =
        RequestParameterDecoder.decode(getHttpRequest(), FileUploadVerifyData.class);
    checkMaximumFileSize(fileUploadVerifyData.getName(), fileUploadVerifyData.getSize());
    checkAuthorizedMimeTypes(fileUploadVerifyData.getName());
    checkSpecificComponentVerifications(fileUploadVerifyData, null);
    return Response.ok().build();
  }

  /**
   * Permits to upload files from multipart http request.
   * If the user isn't authenticated, a 401 HTTP code is returned.
   * If a problem occurs when processing the request, a 503 HTTP code is returned.
   * @return the response in relation with jQuery plugins used on the client side: a html textarea
   * tag that contains a JSON array structure. Each line of this array contains
   * information of an uploaded file :
   * <ul>
   * <li><b>uploadSessionId</b> : the uploaded session identifier</li>
   * <li><b>fullPath</b> : the full path of the uploaded file</li>
   * <li><b>name</b> : the name of the uploaded file (without its path)</li>
   * <li><b>size</b> : the byte size of the uploaded file</li>
   * <li><b>formattedSize</b> : the formatted file size according to the language of user</li>
   * <li><b>iconUrl</b> : the url of the icon that represents the type of the uploaded file</li>
   * </ul>
   */
  @POST
  @Consumes(MediaType.MULTIPART_FORM_DATA)
  @Produces(MediaType.TEXT_HTML)
  public Response uploadFiles() {
    UploadedRequestFile uploadedRequestFile =
        RequestParameterDecoder.decode(getHttpRequest(), UploadedRequestFile.class);

    try {
      UnaryOperator<JSONObject> builder =
          uploadFile(FileUploadData.from(uploadedRequestFile),
              uploadedRequestFile.getRequestFile().getInputStream());
      String jsonFiles = packJSonArrayWithHtmlContainer(a -> a.addJSONObject(builder));
      return Response.ok().entity(jsonFiles).build();
    } catch (WebApplicationException ex) {
      if (AJAX_IFRAME_TRANSPORT.equals(uploadedRequestFile.getXRequestedWith()) &&
          ex.getResponse().getStatus() == Response.Status.PRECONDITION_FAILED.getStatusCode()) {

        // In case of file upload performed by Ajax IFrame transport way,
        // the exception must also be returned into a text/html response.
        ex = createWebApplicationExceptionWithJSonErrorInHtmlContainer(ex);
      }
      throw ex;
    } catch (final Exception ex) {
      SilverLogger.getLogger(this).silent(ex);
      if (ex instanceof InterruptedException) {
        Thread.currentThread().interrupt();
      }
      throw new WebApplicationException(ex, Response.Status.SERVICE_UNAVAILABLE);
    }
  }

  /**
   * Permits to upload one file from http request.
   * If the user isn't authenticated, a 401 HTTP code is returned.
   * If a problem occurs when processing the request, a 503 HTTP code is returned.
   * @return the response in relation with jQuery plugins used on the client side: a html textarea
   * tag that contains a JSON array structure. Each line of this array contains
   * information of an uploaded file :
   * <ul>
   * <li><b>uploadSessionId</b> : the uploaded session identifier</li>
   * <li><b>fullPath</b> : the full path of the uploaded file</li>
   * <li><b>name</b> : the name of the uploaded file (without its path)</li>
   * <li><b>size</b> : the byte size of the uploaded file</li>
   * <li><b>formattedSize</b> : the formatted file size according to the language of user</li>
   * <li><b>iconUrl</b> : the url of the icon that represents the type of the uploaded file</li>
   * </ul>
   */
  @POST
  @Consumes(MediaType.APPLICATION_OCTET_STREAM)
  @Produces(MediaType.TEXT_HTML)
  public Response uploadFile(InputStream inputStream) {
    try {
      String jsonFile = packJSonObjectWithHtmlContainer(
          uploadFile(FileUploadData.from(getHttpServletRequest()), inputStream));
      return Response.ok().entity(jsonFile).build();
    } catch (final WebApplicationException ex) {
      throw ex;
    } catch (final Exception ex) {
      SilverLogger.getLogger(this).silent(ex);
      if (ex instanceof InterruptedException) {
        Thread.currentThread().interrupt();
      }
      throw new WebApplicationException(ex, Response.Status.SERVICE_UNAVAILABLE);
    }
  }

  /**
   * Handles the upload of one file.
   * @param fileUploadData the uploaded file data (except the content)
   * @param inputStream the input stream to upload
   * @return a builder of the JSON representation of the uploaded file (more information on
   * {@link FileUploadResource#uploadFiles()})
   * @throws InterruptedException if the current thread is interrupted while acquiring a semaphore
   * to upload the file without overloading the server.
   * @throws WebApplicationException if the upload is forbidden or if the file is already accessed
   * by another thread.
   */
  private UnaryOperator<JSONObject> uploadFile(
      FileUploadData fileUploadData, InputStream inputStream) throws InterruptedException {

    if (StringUtil.isNotDefined(fileUploadData.getFullPath())) {
      throw new WebApplicationException(Response.Status.BAD_REQUEST);
    }

    // Avoid server overload
    requestLimit.acquire();

    try {
      UploadSession uploadSession = UploadSession.from(fileUploadData.getUploadSessionId());

      if (StringUtil.isDefined(fileUploadData.getComponentInstanceId()) &&
          !componentAccessController
              .isUserAuthorized(getUser().getId(), fileUploadData.getComponentInstanceId())) {
        throw new WebApplicationException(Response.Status.FORBIDDEN);
      }

      if (StringUtil.isDefined(uploadSession.getComponentInstanceId())) {
        if (!uploadSession.getComponentInstanceId()
            .equals(fileUploadData.getComponentInstanceId())) {
          throw new WebApplicationException(Response.Status.FORBIDDEN);
        }
      } else if (StringUtil.isDefined(fileUploadData.getComponentInstanceId())) {
        uploadSession.forComponentInstanceId(fileUploadData.getComponentInstanceId());
      }

      UploadSessionFile uploadSessionFile =
          uploadSession.getUploadSessionFile(fileUploadData.getFullPath());

      // Writing the file on server
      try {
        uploadSessionFile.write(inputStream);
      } catch (IOException ioe) {
        // The file is written currently by an other process
        throw new WebApplicationException(ioe, Response.Status.CONFLICT);
      }

      try {
        // Maximum size
        checkMaximumFileSize(fileUploadData.getName(), uploadSessionFile.getServerFile().length());
        // Mime-Type verified on real data
        checkAuthorizedMimeTypes(uploadSessionFile.getServerFile().getPath());
        // Specific component instance verification
        checkSpecificComponentVerifications(null, uploadSessionFile.getServerFile());
      } catch (Exception e) {
        uploadSession.remove(fileUploadData.getFullPath());
        throw e;
      }

      // JSON response
      return asJSON(uploadSessionFile);

    } finally {
      requestLimit.release();
    }
  }

  /**
   * Checks the maximum size authorized.
   * @param fileName the name of the file to check.
   * @param fileSize the size of the file to check.
   */
  private void checkMaximumFileSize(final String fileName, long fileSize) {
    long maximumFileSize = FileRepositoryManager.getUploadMaximumFileSize();
    if (fileSize > maximumFileSize) {
      LocalizationBundle bundle = ResourceLocator.getLocalizationBundle(
          "org.silverpeas.util.attachment.multilang.attachment",
              getUserPreferences().getLanguage());
      String errorMessage = bundle.getString("attachment.dialog.errorFileSize") +
          " " +
          bundle.getString("attachment.dialog.maximumFileSize") + " (" +
          UnitUtil.formatMemSize(maximumFileSize) + ")";
      errorMessage = MessageFormat.format(errorMessage, fileName);
      MessageNotifier.addError(errorMessage);
      throw new WebApplicationException(
          Response.status(Response.Status.PRECONDITION_FAILED).entity(errorMessage).build());
    }
  }

  /**
   * Checks the authorized mime-types if {@link #getComponentId()} return a defined value.<br>
   * If no defined value is returned by {@link #getComponentId()}, nothing is verified.
   * @param fileName the file name to test.
   */
  private void checkAuthorizedMimeTypes(final String fileName) {
    String componentInstanceId = getComponentId();
    if (StringUtil.isDefined(componentInstanceId)) {

      // Component file filter that contains authorized and forbidden rules
      final ComponentFileFilterParameter componentFileFilter = ComponentFileFilterParameter
          .from(getOrganisationController().getComponentInstance(componentInstanceId).orElse(null));

      try {
        componentFileFilter.verifyFileAuthorized(new File(fileName));
      } catch (Exception e) {
        throw new WebApplicationException(e, Response.Status.PRECONDITION_FAILED);
      }
    }
  }

  /**
   * Checks the authorized mime-types if {@link #getComponentId()} return a defined value.<br>
   * If no defined value is returned by {@link #getComponentId()}, nothing is verified.
   * @param fileUploadData the file upload data (filled when file has not been yet uploaded).
   * @param uploadedFile the uploaded file.
   */
  private void checkSpecificComponentVerifications(FileUploadVerifyData fileUploadData,
      File uploadedFile) {
    String componentInstanceId = getComponentId();
    if (StringUtil.isDefined(componentInstanceId)) {
      try {
        Optional<ComponentInstanceFileUploadVerification> verification =
            ComponentInstanceFileUploadVerification.get(componentInstanceId);
        if (fileUploadData != null) {
          verification.ifPresent(i -> i.verify(componentInstanceId, fileUploadData));
        } else {
          verification.ifPresent(i -> i.verify(componentInstanceId, uploadedFile));
        }
      } catch (Exception e) {
        throw new WebApplicationException(e, Response.Status.PRECONDITION_FAILED);
      }
    }
  }

  /**
   * Builds a JSON representation of the given uploaded file.
   * @param uploadSessionFile the uploaded file into current session.
   * @return a builder of the JSON representation of the uploaded file (more information on
   * {@link FileUploadResource#uploadFiles()})
   */
  private UnaryOperator<JSONObject> asJSON(
      UploadSessionFile uploadSessionFile) {
    return o ->
       o.put("uploadSessionId", uploadSessionFile.getUploadSession().getId())
        .put("fullPath", uploadSessionFile.getFullPath())
        .put("name", uploadSessionFile.getServerFile().getName())
        .put("size", uploadSessionFile.getServerFile().length())
        .put("formattedSize", UnitUtil.formatMemSize(
            new BigDecimal(String.valueOf(uploadSessionFile.getServerFile().length()))))
        .put("iconUrl", FileRepositoryManager
            .getFileIcon(FilenameUtils.getExtension(uploadSessionFile.getServerFile().getName())));
  }

  @DELETE
  @Produces(MediaType.APPLICATION_JSON)
  public Response delete() {
    try {
      UploadSession uploadSession = UploadSession.from(getHttpServletRequest());
      FileUploadData fileToDelete = FileUploadData.from(getHttpServletRequest());
      if (StringUtil.isDefined(fileToDelete.getFullPath())) {
        if (!uploadSession.remove(fileToDelete.getFullPath())) {
          SilverLogger.getLogger(this).error(
              "Trying to delete non existing file with session id '" + uploadSession.getId() +
                  "' and fullPath '" + fileToDelete.getFullPath() + "'");
        }
      } else {
        uploadSession.clear();
      }
      return Response.ok().build();
    } catch (final WebApplicationException ex) {
      throw ex;
    } catch (final Exception ex) {
      throw new WebApplicationException(ex, Response.Status.SERVICE_UNAVAILABLE);
    }
  }

  @Override
  protected String getResourceBasePath() {
    return PATH;
  }

  /*
     * (non-Javadoc)
     * @see com.silverpeas.web.RESTWebService#getComponentId()
     */
  @Override
  public String getComponentId() {
    return getHttpRequest().getHeader(FileUploadData.X_COMPONENT_INSTANCE_ID);
  }
}
